/*
 * Copyright 2012 Dirk Vranckaert
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

package eu.vranckaert.utility.location;

import android.content.Context;
import android.location.Criteria;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Handler;
import android.util.Log;

import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

/**
 * Date: 16/10/12
 * Time: 19:00
 *
 * @author Dirk Vranckaert
 */
public abstract class LocationServiceImpl implements LocationService {
    private static final String LOG_TAG = LocationServiceImpl.class.getSimpleName();

    /* Configuration parameters */
    boolean retrieveLastKnownLocationFirst = true;
    boolean stopIfOneLocationFound = false;
    boolean useLastKnownLocationFallback = false;

    /**
     * The timeout in milliseconds after which all location update requests will be canceled.
     */
    private static final long LOCATION_UPDATES_TIMEOUT = 90000l; //1.5 minute

    private Context ctx;
    private Location bestLocationFound;
    private LocationManager locationManager;
    private List<LocationListener> locationListeners;
    private Timer stopUpdatesTimer;

    public LocationServiceImpl(Context ctx) {
        this.ctx = ctx;
        locationManager = (LocationManager) ctx.getSystemService(Context.LOCATION_SERVICE);
        locationListeners = new ArrayList<LocationListener>();
    }

    @Override
    public void getLocation() {
        // First of all stop any running location update requests
        stopLocationUpdates();

        // Init timer
        stopUpdatesTimer = new Timer();
        stopUpdatesTimer.schedule(new UpdatesCanceledTimerTask(), LOCATION_UPDATES_TIMEOUT);

        if (retrieveLastKnownLocationFirst) {
            // Retrieve the last known position (fastest method available)
            locationFound(getLastKnownLocation(), false);
        }

        // Then retrieve location for each provider
//        Criteria criteria = new Criteria();
//        String bestProvider = locationManager.getBestProvider(criteria, false);
//        getLocationForProvider(bestProvider);

        List<String> providers = locationManager.getAllProviders();
        providers.remove(LocationManager.PASSIVE_PROVIDER);

        if (areAllProvidersDisabled(providers)) {
            Log.d(LOG_TAG, "None of the providers are enabled, cannot retrieve location through provider");
            locationFound(null, false);
        } else {
            for (String provider : providers) {
                getLocationForProvider(provider);
            }
        }
    }

    /**
     * Checks if all providers in a list are enabled or not.
     */
    private boolean areAllProvidersDisabled(List<String> providers) {
        int count = 0;
        for (String provider : providers) {
            Log.d(LOG_TAG, "Checking if provider (" + provider + ") is enabled");
            if (!locationManager.isProviderEnabled(provider)) {
                Log.d(LOG_TAG, "Provider is not enabled");
                count++;
            }
        }

        if (providers.size() == count) {
            return true;
        }
        return false;
    }

    @Override
    public void stopLocationUpdates() {
        stopLocationUpdates(false);
    }

    /**
     * This will stop all location updates that are going on by canceling all the registered listeners. It's some kind
     * of reset of the {@link eu.vranckaert.utility.location.LocationServiceImpl} class because also the {@link eu.vranckaert.utility.location.LocationServiceImpl#bestLocationFound}
     * parameter is being reset.<br/>
     * This method should also be called from your {@link android.app.Activity}'s methods
     * {@link android.app.Activity#onPause()} and {@link android.app.Activity#onStop()}. If you want to restart the
     * location polling again on {@link android.app.Activity#onResume()} you can just call
     * {@link eu.vranckaert.utility.location.LocationServiceImpl#getLocation()}.
     * @param invokedByTimer If true the cancel method of the timer will not be invoked. If false it will be invoked so
     *                       everything really stops working in this service.
     */
    public void stopLocationUpdates(boolean invokedByTimer) {
        for (LocationListener locationListener : locationListeners) {
            locationManager.removeUpdates(locationListener);
        }
        locationListeners.clear();
        bestLocationFound = null;
        if (!invokedByTimer && stopUpdatesTimer != null)
            stopUpdatesTimer.cancel();
    }

    @Override
    public boolean isBusy() {
        if (locationListeners != null && !locationListeners.isEmpty())
            return true;
        return false;
    }

    /**
     * Get the last known location for the best provider possible.
     * @return The last known location. This can be null!
     */
    private Location getLastKnownLocation() {
        Log.d(LOG_TAG, "Trying to retrieve last known location");
        Criteria criteria = new Criteria();
        String bestProvider = locationManager.getBestProvider(criteria, false);
        Location location = locationManager.getLastKnownLocation(bestProvider);
        if (location != null)
            Log.d(LOG_TAG, "Last Known Location found (via provider " + bestProvider + ") = Longitude:" + location.getLongitude() + "|Latitude:" + location.getLatitude());
        else
            Log.d(LOG_TAG, "Could not retrieve the last known location for this provider: " + bestProvider);
        return location;
    }

    /**
     * Start the procedure to retrieve the location for a certain provider. The provider will be checked if he's enabled
     * or not. Only if enabled polling on the provider is started.
     * @param provider The provider to use to retrieve a location.
     */
    private void getLocationForProvider(final String provider) {
        Log.d(LOG_TAG, "Getting location for provider " + provider);
        // Check if the requested provider is enabled
        if (!locationManager.isProviderEnabled(provider)) {
            Log.d(LOG_TAG, "The provider (" + provider + ") is not enabled, no location can be retrieved!");
            return;
        }

        // Acquire a reference to the system Location Manager
        final LocationManager locationManager = (LocationManager) ctx.getSystemService(Context.LOCATION_SERVICE);

        // Define a listener that responds to location updates
        LocationListener locationListener = new LocationListener() {
            public void onLocationChanged(Location location) {
                locationManager.removeUpdates(this);
                locationListeners.remove(this);
                Log.d(LOG_TAG, "Location found (via provider " + provider + ") = Longitude:" + location.getLongitude() + "|Latitude:" + location.getLatitude());
                // Called when a new location is found by the network location provider.
                locationFound(location, false);
            }

            public void onStatusChanged(String provider, int status, Bundle extras) {}

            public void onProviderEnabled(String provider) {}

            public void onProviderDisabled(String provider) {
                Log.d(LOG_TAG, "Location not found because provider is disabled");
                locationManager.removeUpdates(this);
                locationListeners.remove(this);
                locationFound(null, false);
            }
        };
        locationListeners.add(locationListener);

        // Register the listener with the Location Manager to receive location updates
        locationManager.requestLocationUpdates(provider, 0, 0, locationListener);
    }

    private void fallBack() {
        if (useLastKnownLocationFallback && !getLocationResult()) {
            Log.d(LOG_TAG, "Although no location has been the fallback method of 'Last Known Location' will be used");
            Location location = getLastKnownLocation();
            if (location != null) {
                Log.d(LOG_TAG, "The fallback method resulted in a location");
            } else {
                Log.d(LOG_TAG, "Even with the fallback method no location could be found");
            }
            locationFound(location, true);
        }
    }

    /**
     * If a certain method finds a location then this method is called to check if the location is 'better' than the one
     * we might already have. If there is not yet a location this one is accepted as the best one. Afterwards, if this
     * seems to be the new-best-location, the method {@link eu.vranckaert.utility.location.LocationServiceImpl#locationUpdated(android.location.Location)}
     * is called to notify you about the new location. If the provided location is null or not new enough you will not
     * be notified.
     * @param location The location that is found.
     */
    private void locationFound(Location location, boolean fallback) {
        if (location == null && !fallback) {
            if (locationListeners.size() == 0) {
                fallBack();
                done(getLocationResult());
            }
            return;
        } else if (fallback) {
            return;
        }

        Log.d(LOG_TAG, "Checking if the location can be used...");
        if (bestLocationFound == null) {
            Log.d(LOG_TAG, "No location has been found until now so location is ok");
            // The first location is found!
            bestLocationFound = location;
            locationUpdated(bestLocationFound);

            if (stopIfOneLocationFound) {
                done(getLocationResult());
                stopLocationUpdates();
            }

            return;
        }

        if (GoogleUtil.isBetterLocation(location, bestLocationFound)) {
            Log.d(LOG_TAG, "The newly retrieved location is better than the previously found location, using new");
            bestLocationFound = location;
            locationUpdated(bestLocationFound);
        } else {
            Log.d(LOG_TAG, "The newly retrieved location not better then the previously found location, ignoring new");
        }
    }

    @Override
    public abstract void locationUpdated(Location location);

    /**
     * This {@link java.util.TimerTask} class will be called when the timeout of {@link eu.vranckaert.utility.location.LocationServiceImpl#LOCATION_UPDATES_TIMEOUT}
     * milliseconds is reached and will cancel all the ongoing location listeners. If configured with the
     * {@link eu.vranckaert.utility.location.LocationServiceImpl#useLastKnownLocationFallback} flag the last known location will be retrieved if no
     * other location could be retrieved during the process.
     */
    private class UpdatesCanceledTimerTask extends TimerTask {
        @Override
        public void run() {
            Log.d(LOG_TAG, "Location update process is timing out...");

            boolean result = getLocationResult();
            stopLocationUpdates(true);

            returnResult(result);
        }

        private void returnResult(final boolean result) {
            UpdatesCanceledTimerHandler.post(
                    new Runnable() {
                        @Override
                        public void run() {
                            fallBack();
                            done(getLocationResult());
                        }
                    }
            );
        }
    }

    private boolean getLocationResult() {
        if (bestLocationFound == null)  {
            Log.d(LOG_TAG, "No location has been found in the entire process before timeout is reached");
            return false;
        } else {
            Log.d(LOG_TAG, "At least one location has been found in the entire process before timeout is reached");
            return true;
        }
    }

    private static final Handler UpdatesCanceledTimerHandler = new Handler() {};

    @Override
    public abstract void done(boolean result);

    private static class GoogleUtil {
        /**
         * Used to determine if one location is significantly newer or older than another one. <br/>
         * Codes supplied by Google: http://developer.android.com/guide/topics/location/strategies.html#BestEstimate
         */
        private static final int TWO_MINUTES = 1000 * 60 * 2;

        /** Determines whether one Location reading is better than the current Location fix.<br/>
         * Codes supplied by Google: http://developer.android.com/guide/topics/location/strategies.html#BestEstimate
         * @param location  The new Location that you want to evaluate
         * @param currentBestLocation  The current Location fix, to which you want to compare the new one
         */
        protected static boolean isBetterLocation(Location location, Location currentBestLocation) {
            if (currentBestLocation == null) {
                // A new location is always better than no location
                return true;
            }

            // Check whether the new location fix is newer or older
            long timeDelta = location.getTime() - currentBestLocation.getTime();
            boolean isSignificantlyNewer = timeDelta > TWO_MINUTES;
            boolean isSignificantlyOlder = timeDelta < -TWO_MINUTES;
            boolean isNewer = timeDelta > 0;

            // If it's been more than two minutes since the current location, use the new location
            // because the user has likely moved
            if (isSignificantlyNewer) {
                return true;
                // If the new location is more than two minutes older, it must be worse
            } else if (isSignificantlyOlder) {
                return false;
            }

            // Check whether the new location fix is more or less accurate
            int accuracyDelta = (int) (location.getAccuracy() - currentBestLocation.getAccuracy());
            boolean isLessAccurate = accuracyDelta > 0;
            boolean isMoreAccurate = accuracyDelta < 0;
            boolean isSignificantlyLessAccurate = accuracyDelta > 200;

            // Check if the old and new location are from the same provider
            boolean isFromSameProvider = isSameProvider(location.getProvider(),
                    currentBestLocation.getProvider());

            // Determine location quality using a combination of timeliness and accuracy
            if (isMoreAccurate) {
                return true;
            } else if (isNewer && !isLessAccurate) {
                return true;
            } else if (isNewer && !isSignificantlyLessAccurate && isFromSameProvider) {
                return true;
            }
            return false;
        }

        /**
         * Checks whether two providers are the same.<br/>
         * Codes supplied by Google: http://developer.android.com/guide/topics/location/strategies.html#BestEstimate
         */
        private static boolean isSameProvider(String provider1, String provider2) {
            if (provider1 == null) {
                return provider2 == null;
            }
            return provider1.equals(provider2);
        }
    }


    /*
     * Getters and Setters for configuration
     */

    @Override
    public void setRetrieveLastKnownLocationFirst(boolean retrieveLastKnownLocationFirst) {
        if (retrieveLastKnownLocationFirst) {
            stopIfOneLocationFound = false;
        }
        this.retrieveLastKnownLocationFirst = retrieveLastKnownLocationFirst;
    }

    @Override
    public void setStopIfOneLocationFound(boolean stopIfOneLocationFound) {
        if (stopIfOneLocationFound) {
            retrieveLastKnownLocationFirst = false;
        }
        this.stopIfOneLocationFound = stopIfOneLocationFound;
    }

    @Override
    public void setUseLastKnownLocationFallback(boolean useLastKnownLocationFallback) {
        this.useLastKnownLocationFallback = useLastKnownLocationFallback;
    }
}
